컨트롤러가 JSON 응답과 권한 거절 응답을 만드는 방식을 공용 트레이트로 모았다.
같은 사실이 여러 파일에 흩어져 적혀 있었다.
중복된 것 | 사본 수 | 위치 |
|---|---|---|
| 4 |
|
| 4 |
|
| 2 |
|
| 36 |
|
에러 봉투 | 66 |
|
| 13 |
|
권한 확인 → site 조회 → 404 사다리 | 12 |
|
에러 봉투가 특히 나빴다. 같은 {"error": "..."} 를 두 관용구로 적고 있었다. Json.obj("error" -> Json.fromString(msg)) 와 Map("error" -> msg).asJson 은 결과 바이트가 같지만 표기가 달라서, 둘을 맞춰 둘 장치가 없다. 봉투를 바꿔야 할 때 한쪽만 고쳐도 컴파일이 통과한다.
JsonResultsapp/controllers/JsonResults.scala. JSON 응답을 만드는 컨트롤러가 상속한다.
Ok(json: Json) — circe Json 을 application/json 으로 내보낸다.JsonResult(status: Status, json: Json) — Ok 이외의 상태 코드용.JsonError(status: Status, message: String) — 에러 봉투를 만드는 유일한 자리.AdminAuthapp/controllers/AdminAuth.scala. 관리자 권한 판정과 거절 응답을 함께 둔다. 둘은 함께 바뀌기 때문이다.
isAdmin / isSiteAdmin(siteSeq) — AdminLogic 위임.AccessDenied — 거절 응답.Database 를 추상 멤버가 아니라 implicit 파라미터로 받는다. 컨트롤러의 database 는 val 이 아닌 implicit 생성자 파라미터라서 추상 멤버를 구현하지 못한다. 구현하게 하려면 클래스마다 val 을 붙여 공개 접근자를 늘려야 하는데, 그 대가로 얻는 것이 없다.
AccessDenied 는 val 이 아니라 def 다. 트레이트의 val 은 초기화 순서 경쟁에 걸려도 컴파일은 통과하고 생성 시점 NPE 로만 드러난다. Result 생성 비용은 그 위험을 질 만큼이 아니다.
withSiteAdmin / withAdminSiteApiAdminSite 안의 private 헬퍼다(2026-08-10 컨트롤러 분리 때 Api 에서 옮겨 갔다). "권한을 확인하고, site 를 읽고, 없으면 404" 순서를 한 곳에 둔다.
private def withSiteAdmin(seq: Long)(block: Site => Result)(implicit request: RequestHeader): Result = if (!isSiteAdmin(seq)) AccessDenied else SiteLogic.get(seq)(database).fold(siteNotFound(seq))(block)
ApiAdminSite 밖으로 올리지 않았다. 호출부가 전부 ApiAdminSite 안에 있기 때문이다. 필요한 것보다 멀리 올리는 것은 그 자체로 결합이다.
1차 정리는 응답 바이트를 바꾸지 않았고, 아래 불일치를 의도적으로 남겼다. 클라이언트가 무엇을 파싱하는지 확인하지 않은 채 봉투를 바꾸면 조용히 깨지기 때문이다. 이번에 그 확인을 했고, 아래 셋을 통일했다. 남은 자리 하나는 이 절 끝에 있다.
ApiCrawler 의 {"message": ...} 일곱 곳 → JsonError. 소비자는 링크 프리뷰(view.scala.html 의 showPreview) 하나이고, 오류에서 본문을 읽지 않는다 — console.log 후 프리뷰를 지울 뿐이다.Api.pageRevision 의 404 — **체크박스의 전제가 먼저 사라져 있었다.** 세 번째 봉투({"success": false, ...})를 쓰던 404 분기는 그 사이(fbc49b09) 없어졌고, 없는 페이지는 revision 0 으로 200 을 답한다. 남아 있던 것은 {"status": "ok", "pageName", "revision"} 라는 Play-JSON 성공 봉투였고, 유일한 소비자(AhaWiki.Kanban.js 의 fetchLatestRevision)가 .revision 만 읽으므로 {"revision": N} 으로 줄였다.AccessDenied → JsonError(Forbidden, "Access denied."). 관리자 UI 의 읽기 쪽 fetchJson 은 !response.ok 면 본문을 읽지 않고 HTTP <status> 로 던진다. 쓰기 쪽 send 와 계정 화면은 2026-08-31 부터 {"error"} 의 error 를 읽어 메시지로 쓴다 — 다른 모양의 거절 본문을 파싱하는 곳은 없다. Admin 의 HTML 라우트도 같은 JSON 으로 답하게 됐는데, 그것이 결정이다: 관리자 UI 는 비관리자에게 그 경로를 보여주지 않고, 거절 모양 하나가 둘보다 낫다.확인하다 **네 번째 봉투**도 나왔다. Api.renderAhaMark 가 {"status": "ok", "html"} / {"status": "error", "message"} 를 쓰고 있었다. 소비자(AhaWiki.Kanban.js 의 renderComment)가 성공에서 .html 만, 실패에서 payload.message || payload.error 를 읽어 **양쪽 봉투를 다 받아주게** 짜여 있어서 함께 통일했다 — 성공은 {"html": ...}, 실패는 JsonError. 이것으로 Api.scala 에서 Play-JSON import 가 사라졌다.
서버와 클라이언트가 같은 릴리스로 나가므로 이 확인 방식이 성립한다 — 외부에 문서화된 소비자가 있는 /api/v1 은 이번 범위에 없다.
.toString()).as(JSON) 로 다시 훑어 손으로 봉투를 만드는 자리를 더 찾았다. 패턴으로 치환할 때는 치환 대상 목록 자체를 의심해야 한다는 뜻이다.
그래도 하나는 남아 있다. Api.pagePreview 의 404 는 {"success": false, "message"} 를, 403 은 같은 모양을 .toString()).as(JSON) 로 만든다. 소비자는 view.scala.html 의 showPreview 하나이고 오류 본문을 읽지 않으므로 통일해도 깨질 것은 없다 — 통일하려면 여기가 남은 자리다.
Api.adminGenerateSignedReadUrl — 트레이트에 Ok(json) 이 있는데 같은 식을 손으로 적고 있었다.ApiV1 의 revision 충돌 응답 3벌 — revisionConflict 로 뽑았다. latestRevision 을 함께 실어야 해서 JsonError 로는 표현되지 않고 JsonResult 를 쓴다.Api.pageRevision 의 404 — 봉투 모양은 호출부를 모르므로 그대로 두고 JsonResult 만 태웠다.페이지네이션 목록은 {"array": [...], "page": N, "pageSize": N, "count": N} 하나로 답한다. JsonResults.pagedJson 이 만드는 유일한 자리다.
네 endpoint 가 이걸 손으로 만들고 있었고 **이미 갈라져 있었다** — 셋은 page·pageSize 를 보내고 ApiCrawler 는 array·count 만 보냈다. 관리자 UI 가 둘 다 받아주게 짜여 있어서 아무도 몰랐다. 네 번째 endpoint 로 페이징을 시작하는 클라이언트가 있었다면 필요한 필드가 없다는 걸 그때 발견했을 것이다.
푸는 쪽도 하나다. app/assets/js/admin/api.js 의 unwrapPaged 와 pagedParams 가 각각 응답 해체와 질의 파라미터 조립을 맡는다. hook 다섯 곳이 각자 풀고 있었다.
circe 의 Json.toString 은 compact 가 아니라 spaces2 로 찍는다. 실제 바이트는 아래와 같다.
{
"error" : "site not found: 999"
}리팩터링 전 두 관용구가 모두 Json.toString 을 거쳤으므로 이 형태였고, JsonError 도 같다. compact 라고 넘겨짚고 클라이언트에서 문자열을 비교하면 어긋난다.
sbt compile 성공sbt test 성공 — 기존 테스트를 하나도 바꾸지 않았다응답 바이트는 일회성 스펙으로 실제 앱을 띄워 라우터를 통과시켜 확인한 뒤, 앱을 소켓까지 띄우고 curl 로 다시 확인했다.
요청 | 응답 |
|---|---|
| 403 |
| 403 |
| 403 |
| 403 — site 조회보다 권한 확인이 먼저 |
| 401 |
| 403 |
| 200 |
| 200 |
모르는 site 를 물어도 외부인에게는 404 가 아니라 403 이 간다. 권한 확인이 먼저라, 응답이 site 의 존재 여부를 알려주지 않는다. siteSeq 를 파라미터로 받는 favicon·테마 endpoint 다섯도 resolveAdminTargetSiteWithAuth 에서 같은 순서다. 2026-09-10 까지는 그 다섯만 site 를 먼저 읽어서, 모르는 seq 에는 누구에게나 400 을, 있는 seq 에는 403 을 답했다 — 밖에서 어느 사이트 번호가 있는지 셀 수 있었다. ApiSiteAdminSpec 이 이제 둘 다 403 인 것을 지킨다.
withAdminSite 로 접은 endpoint 들의 상태 코드는 기존 ApiSiteAdminSpec 이 지키고, **봉투의 본문과 Content-Type 은 ApiErrorEnvelopeSpec 이 지킨다** — 위 표 중 /api/Admin/Sites, /Admin/Sites, /api/crawler 의 거절 봉투가 그 스펙의 단언이다. 나머지 줄은 curl 로만 확인했다. 1차 정리 때는 본문을 보는 검사가 없어서 일회성 curl 로 쟀는데, 검사 없는 모양이 셋 반까지 불어난 경위가 그것이다.
로컬 실행은 ~/.config/ahawiki/application.local.dev.conf 로 한다. 이 저장소에 없는 로컬 파일이다. 띄우기 전에 알아 둘 것이 둘 있다.
play-redis 뿐이다. caffeine 도 ehcache 도 없어서 Redis 없이는 서비스되지 않는다. Redis 모듈을 아예 켜지 않은 설정으로 띄우면 Guice 가 SyncCacheApi 바인딩을 찾지 못해 부팅 자체가 실패하고, 모듈은 켰는데 Redis 에 닿지 못하면 부팅은 되지만 매 요청이 500 이 된다.base.conf 의 play.evolutions.db.default.autoApply = true 가 여기에도 적용된다. 뭔가 확인하려고 띄우는 것이라면 꺼서 공유 DB 스키마를 건드리지 않게 한다.sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \ -Dplay.evolutions.db.default.autoApply=false \ -Dhttp.port=9123 run
설정된 Redis 에 닿지 못하면 그 부분만 갈아끼우면 된다. 나머지는 설정이 가리키는 곳을 그대로 쓴다.
docker run -d --name ahawiki-local-redis -p 16379:6379 redis:7-alpine sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \ -Dplay.evolutions.db.default.autoApply=false \ -Dplay.cache.redis.host=localhost -Dplay.cache.redis.port=16379 \ -Dhttp.port=9123 run
SiteLogic.get(host) 가 요청 host 로 site 를 찾고, 못 찾으면 Site.notFound 로 떨어진다. 127.0.0.1:9123 으로 직접 부르면 페이지 목록이 빈 배열로 나오는데, 고장이 아니라 그 host 에 걸린 site 가 없다는 뜻이다. 실제 데이터를 보려면 실제 site 의 host 를 실어야 한다.
curl -H "Host: your.wiki.host" http://127.0.0.1:9123/api/pageNames
Similar pages by cosine similarity. Words after page name are term frequency.